feat(rapier-cluster): in-tick imperative physics ops via PhysicsHandle (#121)#126
Merged
martinjms merged 1 commit intofeat/rapier-clusterfrom May 4, 2026
Merged
Conversation
#121) Add entity-keyed in-tick imperative operations to RapierClusterTickContext via a new PhysicsHandle. Closes the "developers can run any Rapier simulation in the cluster the same way they could locally" gap — previously the only way to influence physics in a tick was entity.velocity mutation. New public API surface: - PhysicsHandle::{apply_impulse, apply_force, apply_torque_impulse, set_translation, set_linvel, set_angvel, linvel, angvel, wake, sleep, raycast, intersections_with_shape, create_joint, remove_joint} - RaycastHit { entity_id, time_of_impact, point, normal } + ::new() - JointSpec enum (Fixed / Revolute / Spherical / Prismatic) + JointId newtype - physics: PhysicsHandle<'a> field on RapierClusterTickContext Architecture changes: - Lock-window restructure for the Backend::Rapier path: state lock now held during user on_tick so PhysicsHandle can mutate Rapier state synchronously. Backend::Cluster (plain ClusterSimulation) keeps releasing the lock during user code (legacy behavior; no PhysicsHandle to give it). - Spawn-loop velocity sync now skips entities whose linvel was set imperatively this tick (apply_impulse / set_linvel mark touched_linvel), preserving the imperative override. - Imperative ops on Fixed bodies silently no-op and return false. - Operations on missing entity_ids return false / None without panicking. - Joint cleanup: bodies.remove auto-removes attached joints (existing behavior preserved); remove_joint after despawn returns false. Rapier 0.32 API adjustment: QueryPipeline is constructed transiently per query from broad_phase.as_query_pipeline(...) rather than stored on RapierState (the API changed to return a borrowed view in 0.32). 11 new tests all green: apply_impulse linvel proportional to impulse/mass, apply_force accel over multiple ticks, set_translation propagates, set_linvel imperative override not clobbered, raycast hit and miss, intersections_with_shape, fixed joint holds entities, despawn cleans up joints, missing-entity ops no-panic, Fixed-body imperative no-op. Verification: 87/87 lib tests pass, 3/3 doc examples compile, clippy clean both feature configurations, fmt clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
Author
Self-review against #121 specArchitecture fit ✅
Spec compliance ✅
Test coverage ✅
Rapier 0.32 API translation notes (deviation from spec)
CI: ✅ Verdict: clean implementation, fully spec-compliant including the 6 refinements added to the issue body. Squash-merging into |
This was referenced May 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Quick Summary
RapierClusterTickContext::physics: PhysicsHandle<'a>.on_tick.Uuid, never raw Rapier handles) and behave gracefully on missing ids and Fixed bodies.on_tickfor the Rapier-aware path soPhysicsHandlecan mutate state synchronously. PlainClusterSimulationkeeps the legacy lock-released behavior.Change Type
Impact
RapierClusterSimulationcan now do all the things local Rapier users can — explosions (radius query + apply_impulse), hitscan weapons (raycast), teleport/respawn (set_translation), joints between entities. The unified-entity invariant is preserved (no off-spine bodies; no raw Rapier handles in user code).Backend::Rapierpath only. No replication / hot-path touches. All 76 priorrapier_clustertests still pass after the restructure; 11 new tests cover the new ops.Verification
cargo build -p arcane-infraand--features rapier-cluster)arcane-infra::rapier_cluster— 76 prior + 11 new for Rapier physics: in-tick imperative ops (impulses, forces, raycasts, joints, teleport) #121)ActionGameimperative-ops example)cargo clippy --all-targets -- -D warnings)Decisions made
Lock held during user
on_tickonly forBackend::Rapier. Considered: hold lock for all backends.Reason: Plain
ClusterSimulation(Backend::Cluster) has noPhysicsHandleto give it, so there's no functional reason to hold the lock during its on_tick. Keeps the legacy behavior of releasing the lock for plain sims and avoids any subtle change in lock contention for users who haven't migrated.pending_imperative_linvel: HashSet<Uuid>onRapierState(not onPhysicsHandle). Considered: store on PhysicsHandle, drain after on_tick.Reason: PhysicsHandle has lifetime
'a; storing the set on it would require either pub(crate) field access or an extraction method — both leak the lifecycle. Putting it on the underlying state is symmetric withpending_contact_eventsand avoids lifetime gymnastics.Track only
set_linvel/apply_impulse, notset_translation/set_angvel/apply_force. Considered: track all imperative mutations.Reason: The spawn-loop sync only does
set_linvel(entry.velocity). It doesn't touch translation, angvel, or forces. So only linvel needs the override-protection; the others compose naturally with the sync (apply_force adds during the step regardless of set_linvel; set_translation is read-only from the spawn loop).QueryPipelineconstructed transiently per-query, not stored onRapierState. Considered: store + update once per step (matches the issue spec's wording "QueryPipeline integrated into RapierState, updated post-step").Reason: Rapier 0.32's
QueryPipelineisQueryPipeline<'a>— a borrowed view bound to broad_phase + narrow_phase + bodies + colliders. It can no longer be a stored field. TheBroadPhaseBvh::as_query_pipeline(...)factory builds it cheaply on demand.PhysicsHandle::raycast/intersections_with_shapeconstruct it inline. Functionally equivalent to "post-step refreshed" — the broad_phase BVH is updated byphysics_pipeline.stepso all queries inside on_tick (which runs next tick, after the prior step) see the latest BVH.JointSpeccarries axis as plainVec3; normalized insidecreate_joint. Considered: takeUnitVector/Unit<Vector>.Reason: Rapier 0.32 takes
Vectordirectly for joint axes (auto-normalizes via the builder); user-facing API is simpler. We callto_rapier(axis).normalize()to defend against zero/non-unit input.JointId(ImpulseJointHandle)opaque newtype with private field. Considered: expose the Rapier handle directly viapub.Reason: Keeps the unified-entity invariant — user never holds a raw Rapier handle. JointId is
Copy + Eq + Hashfor ergonomic storage in user-side maps.#[non_exhaustive]onJointSpecvariants andRaycastHitwithpub const fn new(...)constructor on RaycastHit. Considered: skip the constructor.Reason: Same
#[non_exhaustive]external-construction issue we hit in Rapier physics: per-entity body kind + material + collision filtering + sensor hooks #120 — can't construct via struct literal outside the crate. Constructor allows users to buildRaycastHitinstances in test fixtures / mock setups. JointSpec has no constructor needed since users always go throughcreate_joint.Imperative ops on
Fixedbodies silently no-op (returnfalse). Considered: panic; or accept the op and let Rapier handle it.Reason: Per the issue's normalization spec — gameplay code shouldn't have to query body kind before applying force. Silent no-op + false return is the gentlest contract; matches the existing missing-entity behavior.
Test fixture
ScriptedSim<F>parameterized by closure. Considered: per-test struct (existing pattern from Rapier physics: per-entity body kind + material + collision filtering + sensor hooks #120).Reason: 11 Rapier physics: in-tick imperative ops (impulses, forces, raycasts, joints, teleport) #121 tests all need different in-tick behaviors. A closure-parameterized fixture (
Fn(&mut PhysicsHandle, u64)) plusrun_with_actionhelper drops boilerplate from ~15 lines per test to ~5. Per-test structs still used where the test needs richer state (joint despawn cleanup, Fixed-body sim).Implementation notes
Architecture fit
PhysicsHandle<'a>borrows&'a mut RapierState— same lifetime as the surroundingRapierClusterTickContext<'a>. Aftersim.on_tick(&mut rapier_ctx)returns, the inner block ends and the borrow on state releases, allowingrun_physics_phase(&mut state, ctx)to use state directly.RapierClusterSim::run_physics_phase(&self, state, ctx)extracts the despawn / spawn / set_linvel-with-skip / step / sync_outputs sequence from the old monolithic on_tick. All three backends now share this phase via clean factoring.pending_imperative_linvelis cleared at the start of every tick (in all three backend branches) and populated byapply_impulse/set_linvel. The spawn-loop sync skips entities present in the set.Rapier 0.32 API notes
QueryPipeline<'a>is now a borrowed view fromBroadPhaseBvh::as_query_pipeline(dispatcher, bodies, colliders, filter). Cannot be stored as a field.Vector(glam Vec3), notPoint::from(...).Vector; we.normalize()defensively.RigidBody::linvel()/angvel()/translation()return by value (Vector), not by reference.Ray::new(origin: Vector, dir: Vector)— both args are Vector; hit point isray.origin + ray.dir * toi.Pose::from_translation(vector)is the new translation-only constructor (replacesIsometry::translation(x, y, z)).Module docs
# In-tick imperative opssection enumerating the new methods + the lock-window contract.# Exampleblock with anActionGameimpl showing hitscan raycast, explosion radius query + apply_impulse, and contact-driven teleport.Tests
11 new tests appended to
mod tests. Shared fixtureScriptedSim<F>+run_with_actionhelper for the closure-driven cases; per-test impl structs fordespawn_cleans_up_attached_jointsandimperative_ops_on_fixed_body_are_no_ops(richer state).Reference
docs/architecture/entity-model.md